BuildContext를 비동기 구간 뒤에 사용할 때 주의점

BuildContext를 비동기 구간 뒤에 사용할 때 주의점

한눈에 보기

BuildContext는 서비스 locator가 아니라 현재 Widget이 놓인 Element 위치에 대한 handle이다. await 동안 사용자가 화면을 닫으면 그 위치가 unmount될 수 있다. 비동기 구간 뒤 같은 context를 사용하려면 해당 context.mounted를 확인해야 한다. 다만 mounted 검사는 크래시를 막는 경계일 뿐, 불필요한 요청·중복 제출·잘못된 내비게이션 의도를 해결하지는 않는다.

저장 버튼을 누른 뒤 API 요청이 끝나면 화면을 닫는 코드를 생각해 보자.

Future<void> save(BuildContext context) async {
  final result = await repository.save();
  Navigator.of(context).pop(result);
}

정상적인 네트워크에서는 잘 동작한다. 하지만 요청이 느린 동안 사용자가 시스템 뒤로가기로 화면을 닫을 수 있다. 응답이 돌아왔을 때 context가 가리키던 Element는 더 이상 트리에 없을 수 있다.

이 버그는 항상 재현되지 않는다. API 응답과 사용자의 화면 전환 순서가 겹칠 때만 나타난다. 그래서 analyzer의 use_build_context_synchronously 경고를 단순한 스타일 규칙으로 취급하기 쉽다.

문제를 제대로 해결하려면 context의 정체, async gap, 작업의 소유 수명을 함께 이해해야 한다.

목차

BuildContext는 트리 위치를 가리킨다

BuildContext는 현재 앱의 모든 기능에 접근하는 전역 객체가 아니다. Flutter API에서 context는 Widget tree의 특정 위치를 나타내는 handle이며 실제로 Element가 이 interface를 구현한다.

flowchart LR
    W["Widget configuration"] --> E["Element"]
    E --> C["BuildContext interface"]
    C --> A["Theme·Navigator·MediaQuery
가까운 ancestor 조회"]

Theme.of(context)는 이 위치 위쪽의 Theme을 찾고, Navigator.of(context)는 이 위치를 포함하는 Navigator를 찾는다. 같은 코드라도 어느 Builder의 context를 사용하는지에 따라 결과가 다를 수 있다.

MaterialApp(
  home: Builder(
    builder: (context) {
      return ElevatedButton(
        onPressed: () {
          Navigator.of(context).push(...);
        },
        child: const Text('열기'),
      );
    },
  ),
)

따라서 context의 유효성은 해당 Element의 수명과 연결된다. 한 번 unmount된 BuildContext는 다시 mounted 상태가 되지 않는다.

이 구조는 Flutter Widget의 불변성과 rebuild 이해하기에서 설명한 Widget·Element tree와 이어진다.

async gap 동안 달라질 수 있는 것

async 함수는 await를 만났을 때 실행을 중단하고 event loop에 제어권을 돌려준다.

final result = await repository.save();

중단된 동안 다음 일이 일어날 수 있다.

sequenceDiagram
    participant U as 사용자
    participant P as 편집 화면
    participant API as 서버
    participant N as Navigator

    U->>P: 저장 tap
    P->>API: 요청 시작
    U->>N: 뒤로가기
    N->>P: route 제거·dispose
    API-->>P: 요청 완료
    P-xN: 오래된 context로 pop 시도

async gap은 네트워크 요청에만 생기지 않는다.

await Future<void>.delayed(Duration.zero);
await showDialog<void>(...);
await controller.forward();
await file.readAsString();

await 뒤에 실행이 재개되기 전 framework가 tree를 변경할 기회가 있다면 같은 점검이 필요하다.

빠른 Future도 계약은 같다

현재 테스트에서 즉시 완료된다고 context 수명을 가정하지 않는다. 구현이 캐시에서 네트워크로 바뀌거나 animation이 추가되면 순서가 달라질 수 있다.

mounted 검사를 올바른 대상에 적용하기

함수 parameter나 Builder local variable로 받은 context를 await 뒤에 사용한다면 그 context의 mounted를 확인한다.

Future<void> onSave(BuildContext context) async {
  final result = await repository.save();

  if (!context.mounted) return;

  Navigator.of(context).pop(result);
}

State의 context property를 사용하는 instance method라면 State.mounted를 확인할 수 있다.

class _EditorPageState extends State<EditorPage> {
  Future<void> onSave() async {
    final result = await widget.repository.save();

    if (!mounted) return;

    Navigator.of(context).pop(result);
  }
}

중요한 것은 나중에 사용할 바로 그 context의 수명을 검사하는 것이다.

Future<void> submit(
  BuildContext pageContext,
  BuildContext dialogContext,
) async {
  await repository.save();

  if (!pageContext.mounted) return;
  Navigator.of(dialogContext).pop();
}

위 코드는 pageContext를 검사하고 dialogContext를 사용한다. page가 mounted여도 dialog는 이미 닫혔을 수 있다. 검사 대상과 사용 대상이 다르므로 안전하지 않다.

if (!dialogContext.mounted) return;
Navigator.of(dialogContext).pop();

mounted가 true라는 것은 context API를 사용할 수 있다는 뜻이지 원하는 route가 여전히 top인지, 같은 요청 결과를 기다리고 있는지까지 보장하지 않는다. 이 구분이 중요하다.

mounted 검사는 취소가 아니다

다음 코드는 dispose 이후 setState나 Navigator 호출을 막는다.

final result = await repository.load();
if (!mounted) return;
setState(() => data = result);

하지만 화면이 닫혀도 요청은 끝까지 실행된다.

취소 가능한 작업이라면 State 수명에 맞춰 중단한다.

class _SearchPageState extends State<SearchPage> {
  StreamSubscription<SearchResult>? _subscription;

  void startSearch(String keyword) {
    _subscription?.cancel();
    _subscription = widget.repository
        .search(keyword)
        .listen(_onResult);
  }

  void _onResult(SearchResult result) {
    if (!mounted) return;
    setState(() => latest = result);
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }
}

모든 Dart Future가 일반적으로 취소 가능한 것은 아니다. 사용하는 HTTP client나 repository가 cancellation API를 제공하는지 확인하고, 제공하지 않으면 최소한 오래된 결과를 반영하지 않는 sequence 정책을 둔다.

int _requestVersion = 0;

Future<void> search(String keyword) async {
  final version = ++_requestVersion;
  final result = await widget.repository.searchOnce(keyword);

  if (!mounted || version != _requestVersion) return;

  setState(() {
    latest = result;
  });
}

mounted와 최신 요청 여부는 서로 다른 조건이다.

검사 보장하는 것 보장하지 않는 것
mounted context가 아직 tree에 연결됨 결과가 최신 요청인지
request version 최신 작업 결과인지 Widget이 아직 존재하는지
작업 취소 불필요한 진행을 줄임 서버가 이미 시작한 작업의 rollback
disabled 일반 UI의 중복 tap 방지 외부 호출자의 중복 요청

저장 후 화면을 닫는 안전한 흐름

저장 흐름에는 context 검사 외에도 loading, 중복 제출, 오류 표시, 완료 뒤 navigation 정책이 필요하다.

class _EditProfilePageState extends State<EditProfilePage> {
  bool _saving = false;
  String? _errorMessage;

  Future<void> _save() async {
    if (_saving) return;

    setState(() {
      _saving = true;
      _errorMessage = null;
    });

    try {
      final result = await widget.repository.save(
        name: _nameController.text,
      );

      if (!mounted) return;

      Navigator.of(context).pop(result);
    } on ValidationException catch (error) {
      if (!mounted) return;

      setState(() {
        _errorMessage = error.userMessage;
      });
    } catch (error, stackTrace) {
      reportError(error, stackTrace);

      if (!mounted) return;

      setState(() {
        _errorMessage = '잠시 후 다시 시도해 주세요.';
      });
    } finally {
      if (mounted) {
        setState(() {
          _saving = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('프로필 편집')),
      body: Column(
        children: [
          if (_errorMessage case final message?)
            Text(message),
          ElevatedButton(
            onPressed: _saving ? null : _save,
            child: Text(_saving ? '저장 중' : '저장'),
          ),
        ],
      ),
    );
  }
}

이 예시는 구조를 설명하기 위한 재구성 코드다. 실제 앱에서는 입력 validation, 접근성 있는 오류 연결, repository 오류 type, 저장 멱등성을 별도로 설계한다.

finally가 navigation pop 이후 실행된다는 점도 주의한다. pop으로 State가 즉시 dispose될 수 있으므로 mounted를 다시 확인한다. 완료 후 route를 닫는 흐름에서는 굳이 _saving = false로 되돌릴 필요가 없는 구조로 분기할 수도 있다.

final result = await widget.repository.save(...);
if (!mounted) return;
Navigator.of(context).pop(result);
return;

성공 경로와 실패 경로의 UI 수명을 명시적으로 나누면 불필요한 setState가 줄어든다.

서버 무결성은 별도다

버튼을 disabled해도 네트워크 재시도나 이중 tap race가 완전히 사라지는 것은 아니다. 결제·주문 같은 쓰기는 서버 idempotency key와 상태 전이 검증이 필요하다.

Snackbar와 Dialog에서 context 다루기

Snackbar

요청 완료 뒤 현재 화면의 ScaffoldMessenger를 사용하려면 context가 여전히 mounted인지 확인한다.

final result = await repository.archive(itemId);

if (!context.mounted) return;

ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(content: Text('${result.count}개를 보관했습니다.')),
);

사용자가 다른 route로 이동했어도 앱 전역에서 성공 알림을 보여야 한다면 작업을 시작한 작은 Widget의 context에 묶는 것이 맞는지 다시 생각해야 한다. 상위 coordinator나 전역 messenger key를 가진 알림 계층이 더 적합할 수 있다.

다만 global key를 서비스 locator처럼 남용하지 않는다. “어느 화면에서 이 알림을 보아야 하는가?”라는 제품 정책을 먼저 정한다.

Progress Dialog

비동기 작업 중 dialog를 열고 완료 뒤 닫는 코드는 두 route의 수명이 얽힌다.

showDialog<void>(
  context: context,
  barrierDismissible: false,
  builder: (dialogContext) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  },
);

await repository.save();

사용자나 시스템 navigation으로 dialog 또는 page가 먼저 사라질 수 있다. 가능하면 dialog route를 수동으로 제어하기보다 page 내부 loading overlay나 button progress 상태로 표현하면 수명 관계가 단순해진다.

Stack(
  children: [
    const EditorForm(),
    if (_saving)
      const Positioned.fill(
        child: ColoredBox(
          color: Color(0x66000000),
          child: Center(
            child: CircularProgressIndicator(),
          ),
        ),
      ),
  ],
)

Dialog에서 사용자가 선택한 결과를 기다리는 것은 자연스러운 패턴이다.

final confirmed = await showDialog<bool>(
  context: context,
  builder: (dialogContext) {
    return AlertDialog(
      title: const Text('삭제할까요?'),
      actions: [
        TextButton(
          onPressed: () {
            Navigator.of(dialogContext).pop(false);
          },
          child: const Text('취소'),
        ),
        FilledButton(
          onPressed: () {
            Navigator.of(dialogContext).pop(true);
          },
          child: const Text('삭제'),
        ),
      ],
    );
  },
);

if (!context.mounted || confirmed != true) return;
await deleteItem();

여기서 dialog 내부 버튼은 async gap 없이 dialogContext를 사용한다. showDialog를 기다린 뒤 page context를 다시 사용할 때는 page context의 mounted를 확인한다.

내비게이션을 비즈니스 로직에서 분리하기

Repository나 service가 BuildContext를 받는 구조는 피하는 편이 좋다.

class ProfileRepository {
  Future<void> save(
    BuildContext context,
    ProfileDraft draft,
  ) async {
    await api.save(draft);
    Navigator.of(context).pop();
  }
}

이 구조는 데이터 저장 계층이 화면 위치와 route 정책을 알아야 하고 단위 테스트도 어려워진다.

service는 결과를 반환하고 UI가 navigation을 결정한다.

class SaveProfileUseCase {
  SaveProfileUseCase(this.repository);

  final ProfileRepository repository;

  Future<SaveProfileResult> execute(
    ProfileDraft draft,
  ) {
    return repository.save(draft);
  }
}
final result = await saveProfile.execute(draft);

if (!mounted) return;

switch (result) {
  case SaveProfileSuccess():
    Navigator.of(context).pop(result.profile);
  case SaveProfileConflict():
    setState(() {
      errorMessage = '다른 기기에서 수정된 내용이 있습니다.';
    });
}

더 복잡한 앱에서는 state management 계층이 saving → success/error 상태를 만들고 route coordinator가 그 상태 변화를 관찰해 navigation할 수 있다. 이때도 한 성공 상태로 여러 listener가 중복 navigation하지 않도록 event 소비 정책을 정해야 한다.

flowchart LR
    UI["UI intent"] --> VM["View model / Controller"]
    VM --> UC["Use case"]
    UC --> VM
    VM --> ST["Success / Error state"]
    ST --> UI
    UI --> NAV["현재 route에서 navigation"]

context는 UI 경계에 남고 비즈니스 로직은 Flutter tree 수명에서 독립된다.

동시 요청과 중복 실행 막기

mounted가 true여도 두 저장 요청이 동시에 완료되면 두 번 pop할 수 있다.

onPressed: _save

빠른 double tap, accessibility action 반복, 외부 callback으로 _save가 중복 실행될 수 있다.

Future<void> _save() async {
  if (_saving) return;

  setState(() => _saving = true);

  try {
    final result = await repository.save();
    if (!mounted) return;
    Navigator.of(context).pop(result);
  } finally {
    if (mounted) {
      setState(() => _saving = false);
    }
  }
}

UI guard는 사용 경험을 개선한다. 중요한 쓰기 작업은 서버에서도 중복을 처리해야 한다.

검색이나 자동 완성처럼 새 요청이 이전 요청을 대체하는 경우에는 “하나만 실행”보다 latest wins 정책이 맞다.

작업 동시성 정책 예
저장 버튼 실행 중 새 호출 무시
검색 이전 작업 취소, 최신 결과만 반영
pagination 같은 page 중복 요청 병합
결제 client guard + idempotency key
이미지 처리 queue 또는 명시적 cancel

async context 문제는 결국 화면 수명과 작업 동시성의 교차점이다.

context에서 필요한 값을 await 전에 읽기

비동기 작업이 context와 무관한 값을 필요로 한다면 await 전에 읽어 immutable local value로 보관할 수 있다.

final locale = Localizations.localeOf(context);
final userMessage = AppLocalizations.of(context)!.savedMessage;

final result = await repository.save(locale: locale);

if (!context.mounted) return;

ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(content: Text(userMessage)),
);

localeuserMessage는 단순 값이므로 await 동안 context가 unmount되어도 읽는 행위 자체는 끝났다. 하지만 Snackbar를 표시하는 동작은 현재 tree가 필요하므로 mounted 검사는 여전히 필요하다.

NavigatorState나 ThemeData를 미리 저장하면 lint를 피할 수 있다고 기계적으로 생각하지 않는다.

final navigator = Navigator.of(context);
await repository.save();
navigator.pop();

context를 await 뒤에 직접 읽지 않았지만 그 Navigator가 여전히 원하는 route 관계를 갖는지 별도의 수명 문제가 남는다. lint 통과와 의미적 안전성은 다르다.

다음처럼 async 작업에 context가 전혀 필요 없게 만드는 것이 가장 단순하다.

final draft = ProfileDraft(
  name: nameController.text,
  email: emailController.text,
);

final result = await repository.save(draft);

if (!mounted) return;
_handleSaveResult(result);

Lint를 끄기 전에 구조를 바꾸기

use_build_context_synchronously lint는 async gap 뒤 context 사용을 알려 준다.

linter:
  rules:
    use_build_context_synchronously: true

경고가 발생했을 때 선택 순서는 다음과 같다.

  1. async gap 뒤 context 사용 자체를 제거할 수 있는가?
  2. 필요한 값을 await 전에 값으로 추출할 수 있는가?
  3. 해당 context의 mounted를 바로 앞에서 검사했는가?
  4. 작업과 navigation 책임을 분리할 수 있는가?
  5. 작업을 화면 dispose 때 취소해야 하는가?

다음처럼 이유 없이 ignore하면 race가 그대로 남는다.

await repository.save();

Navigator.of(context).pop();

Analyzer가 모든 의미적 오류를 찾는 것도 아니다. 다른 context의 mounted를 검사하거나 객체에 context를 저장해 우회하면 정적 규칙을 통과해도 위험할 수 있다.

mounted check는 사용 지점 가까이에 둔다

검사와 context 사용 사이에 또 다른 await가 있으면 다시 async gap이 생긴다. 각 gap 이후 유효성을 새로 확인한다.

await firstTask();
if (!context.mounted) return;

await secondTask();
if (!context.mounted) return;

Navigator.of(context).pop();

더 나은 구조라면 context가 필요한 마지막 UI 동작을 한 곳으로 모은다.

재현 가능한 테스트 만들기

빠른 mock 응답만 사용하면 문제를 놓친다. Completer로 완료 시점을 제어해 화면을 먼저 닫는 테스트를 만든다.

testWidgets(
  '저장 완료 전에 화면이 닫혀도 navigation 오류가 없다',
  (tester) async {
    final completer = Completer<SaveResult>();
    final repository = FakeRepository(
      saveFuture: completer.future,
    );

    await tester.pumpWidget(
      TestApp(repository: repository),
    );

    await tester.tap(find.text('편집'));
    await tester.pumpAndSettle();

    await tester.tap(find.text('저장'));
    await tester.pump();

    await tester.pageBack();
    await tester.pumpAndSettle();

    completer.complete(const SaveResult.success());
    await tester.pumpAndSettle();

    expect(tester.takeException(), isNull);
  },
);

추가로 다음 순서를 테스트한다.

단지 exception이 없는지만 보지 않는다. 잘못된 route가 pop되지 않았는지, 다른 화면에 Snackbar가 나타나지 않았는지, 최신 결과만 반영됐는지를 확인한다.

실무 체크리스트

Context 사용

작업 수명

Navigation과 피드백

마무리

BuildContext를 async gap 뒤에 사용할 때의 문제는 await 문법 자체가 아니다. await 동안 context가 가리키는 Element 위치의 수명이 끝날 수 있다는 점이다.

해당 context가 여전히 mounted인지 사용 직전에 확인하면 unmounted context 접근을 막을 수 있다. State method에서는 mounted, local이나 parameter context에서는 context.mounted를 사용한다. 다른 context의 상태를 대신 확인해서는 안 된다.

그러나 mounted는 최소한의 유효성 검사다. 이미 시작한 작업을 취소하지 않고, 오래된 결과와 중복 요청을 구분하지 않으며, 어떤 route를 닫아야 하는지도 결정하지 않는다. 작업 수명을 화면과 맞추고, 최신 요청 정책을 두며, 데이터 계층에서 navigation을 분리해야 한다.

async gap 뒤의 context 문제를 안전하게 푼다는 것은 한 줄의 mounted guard를 추가하는 데서 끝나지 않는다. UI 위치의 수명, 비동기 작업의 수명, 완료 뒤 사용자 흐름을 같은 정책으로 맞추는 일이다.

관련 노트

참고 자료